package javango.forms;

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javango.forms.fields.BoundField;
import javango.forms.fields.Field;
import javango.forms.fields.FieldFactory;

import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.name.Named;

public class AbstractForm implements Form {

	public final static String NON_FIELD_ERRORS = "__all__";

	private final static Log log = LogFactory.getLog(AbstractForm.class);

	protected Map<String, Field<?>> _fields = new LinkedHashMap<String, Field<?>>();  // use caution when using this directly, user getter (getFields());
	protected Map<String, String> errors; // null until isValid is called
	protected Map<String, Object> cleanedData; // null until isValid is called

	protected Map<String, String[]> data;
	protected Map<String, FileItem> fileData;
	
	protected Map<String, Object> initial;
	protected String prefix;
	protected String id = "id_%s"; // set to null to disable ID.
	protected boolean readOnly;

	protected boolean init = false;

	@Inject(optional = true)
	@Named("javango.forms.errorCssClass")
	protected String errorCssClass = null;
	
	@Inject(optional = true)
	@Named("javango.forms.requiredCssClass")
	protected String requiredCssClass = null;

	protected FieldFactory fieldFactory;

	/**
	 *  if injected clean(class) will use it to create the instance.
	 */
	@Inject
	Injector injector;
	
	@Inject
	public AbstractForm(FieldFactory fieldFactory) {
		this.fieldFactory = fieldFactory;
	}

	public Form bind(Map<String, String[]> map) {
		this.data = map == null ? null : new HashMap<String, String[]>(map);
		return this;
	}
	
	public Form bind(Map<String, String[]> map, Map<String, FileItem> fileMap) {
		bind(map);
		this.fileData = fileMap == null ? null : new HashMap<String, FileItem>(fileMap);
		return this;
	}
	/**
	 * Set this form's initial values to the cooresponding fields in the provided bean.
	 * @param bean
	 * @return
	 */
	public Form setInitial(Object bean) {
		if (bean == null) {
			return this;
		}
		if (bean instanceof Map) {
			return setInitial((Map)bean);
		}
		try {
			for (Entry<String, Field<?>> e : getFields().entrySet()) {
				if (PropertyUtils.isReadable(bean, e.getKey())) {
					this.getInitial().put(e.getKey(), PropertyUtils.getProperty(bean, e.getKey()));
				}
			}
			// TODO what should really be done on these exceptions...  continue with the rest,  throw something
		} catch (IllegalAccessException e) {
			log.error(e,e);
		} catch (NoSuchMethodException e) {
			log.error(e,e);
		} catch (InvocationTargetException e) {
			log.error(e,e);
		}
		return this;
	}

	/**
	 * Returns true if this is a bound form,  bound forms have been associated with input from a user.
	 * @return
	 */
	public boolean isBound() {
		return this.data != null;
	}

	/**
	 * If this form has a prefix, returns the field name with the form's prefix appended.
	 * @param fieldName
	 */
	public String addPrefix(String fieldName) {
		return getPrefix() == null ? fieldName : String.format("%s-%s", getPrefix(), fieldName);
	}

	/**
	 * Cleans all of data and populates errors and cleanedData
	 */
	protected void fullClean() {
		if (cleanedData != null) return;  // we have already run full clean I don't think we should run again.
		errors = new HashMap<String, String>();
		if (!isBound()) return;
		cleanedData = new HashMap<String, Object>();

		for (Entry<String, Field<?>> e : getFields().entrySet()) {			
			Field<?> field = e.getValue();
			String name = field.getName() == null ? e.getKey() : field.getName();

			if (field.getName() == null) { // TODO This is here in case a field does not know its name..
				field.setName(name);
			}

			String[] value = field.getWidget().valueFromMap(data, addPrefix(name));

			try {				
				cleanedData.put(name, field.clean(value, errors));
				if (errors.containsKey(field.getName())) {
					cleanedData.remove(field.getName());
				} else {		
					try {
						Method m = this.getClass().getMethod("clean_" + name);
						cleanedData.put(name, m.invoke(this));
					} catch (NoSuchMethodException ex) {
						// 	ouch is this the best way to figure out that a clean_ method does not exist,  seems an exception might be too slow.
					} catch (InvocationTargetException ex) { // TODO should this be logged
						if (ex.getCause() instanceof ValidationException) {
							throw (ValidationException)ex.getCause();
						}
						LogFactory.getLog(AbstractForm.class).error(ex,ex);
						LogFactory.getLog(AbstractForm.class).error(ex.getCause(),ex.getCause());
						throw new RuntimeException(
								String.format("Unable to run clean_%s method due to an InvocationTargetException '%s'", name, ex.getCause().getMessage()));
					} catch (IllegalAccessException ex) {
						LogFactory.getLog(AbstractForm.class).error(ex,ex);
						throw new RuntimeException(
								String.format("Unable to run clean_%s method due to an IllegalAccessException", name));
					}
				}
			} catch (ValidationException ex) {
				errors.put(name, ex.getMessage());
				cleanedData.remove(name);
			}
		}

		if (errors.isEmpty()) {
			try {
				clean();
			} catch (ValidationException ex) {
				errors.put(NON_FIELD_ERRORS, ex.getMessage()); // TODO allow more than one non-field error??
			}
		}

		if (!errors.isEmpty()) {
			cleanedData = null;
		}
	}

	/**
	 * Hook for doing any additional form-wide cleaning after fullClean has been called,  at this point Field.clean will have
	 * been called for all fields.
	 */
	protected void clean() throws ValidationException {

	}

	/**
	 * Return true if this form is valid.
	 * @return
	 */
	public boolean isValid() {	
		return isBound() && getErrors().isEmpty();
	}

	/**
	 * Returns a Map of field errors
	 * TODO If we go with Hibernate validator, should this return a Map/List of hibernate validators
	 * @return
	 */
	public Map<String, String> getErrors() {
		if (errors == null) {
			fullClean();
		}
		return errors;
	}

	/**
	 * Cleans the data from this form into the specified object, returns null if the form is not valid.
	 * 
	 * @param object
	 */
	public <T> T clean(T bean) {
		fullClean();
		if (!errors.isEmpty()) return null;

		for (Entry<String, Object> e : getCleanedData().entrySet()) {
			String fieldName = e.getKey();
			Field f = getFields().get(fieldName);
			if (f == null || f.isEditable()) {
				if (log.isDebugEnabled()) log.debug(String.format("Cleaning : '%s'", fieldName));

				if (log.isDebugEnabled()) log.debug("Found data : '%s'" + e.getValue());
				if (PropertyUtils.isWriteable(bean, fieldName)) {
					try {
						if (log.isDebugEnabled()) log.debug("Trying to set");
						PropertyUtils.setProperty(bean, fieldName, e.getValue());
					} catch (InvocationTargetException ex) {
						log.error(ex,ex);
					} catch (IllegalAccessException ex) {
						log.error(ex,ex);
					} catch (NoSuchMethodException ex) {
						log.error(ex,ex);
					}
				}
			}
		}
		return bean;
	}

	/**
	 * Cleans the data in this form into a new bean of the specified class.
	 * @param objectClass
	 * @return
	 */
	public <T> T clean(Class<T> objectClass) {
		try {
			T bean = null;
			if (injector != null) {
				bean = injector.getInstance(objectClass);
			} else {
				bean = objectClass.newInstance();
			}
			return clean(bean);
		} catch (IllegalAccessException e) {
			log.error(e,e);
		} catch (InstantiationException e) {
			log.error(e,e);
		}
		return null;
	}

	/**
	 * 
	 * @param field
	 * @return
	 */
	protected Field loadField(java.lang.reflect.Field field) {
		return loadField(field, true);
	}
	
	
	/**
	 * Loads the specified field, optionally processing annotations.  Annotations are optional at this time because a subclass may want
	 * more fine grained processing
	 * 
	 * @param field
	 * @param processAnnotations
	 * @return
	 */
	protected Field loadField(java.lang.reflect.Field field, boolean processAnnotations) {
		if (!Field.class.isAssignableFrom(field.getType())) {
			if (log.isDebugEnabled()) log.debug("Field not extended from Field class, skipping: " + field.getName());
			return null;
		}
		if (_fields.containsKey(field.getName())) {
			if (log.isDebugEnabled()) log.debug("Field already in field list, skipping: " + field.getName());
			return null;			
		}

		try {
			Field<?> baseField = (Field<?>)field.get(this);
			if (baseField == null) {
				baseField = fieldFactory.newField((Class<? extends Field>)field.getType());
				field.set(this, baseField);
			}

			baseField.setName(field.getName());
			_fields.put(field.getName(), baseField);

			if (processAnnotations) {
				Annotation[] annotations = field.getAnnotations();
				for (Annotation annotation: annotations) {
					baseField.handleAnnotation(annotation);
				}
			}

			return baseField;
		} catch (IllegalAccessException e) {
			log.error("Unable to load field into form,  change visiblity to public: " + e,e);
			return null;
		}		
	}

	/**
	 * Actually setup the form,  populate the field list with all Field typed properties in this form or any super classes.
	 */
	protected void init() {
		if (init) return; // no reason to call twice!!

		Class<?> cls = this.getClass();
		while(cls != null) {
			java.lang.reflect.Field[] classFields = cls.getDeclaredFields();
			for (int i=0; i<classFields.length; i++) {
				loadField(classFields[i]);
			}
			cls = cls.getSuperclass();
		}
		init = true;
	}

	/**
	 * 
	 */
	public String asTable() {
		StringBuilder b = new StringBuilder();
		StringBuilder hidden_fields = new StringBuilder();

		for (Entry<String, Field<?>> entry : getFields().entrySet()) {
			Field<?> field = entry.getValue();
			String fieldName = field.getName() == null ? entry.getKey() : field.getName();
			BoundField bf = new BoundField(field, this, fieldName);

			// any class attributes that should be applied to the table row.
			String cssClasses = bf.getCssClasses(); 
			String htmlClassAttr = cssClasses == null ? "" : String.format(" class=\"%s\"", cssClasses); 
			
			if (bf.isHidden()) {
				hidden_fields.append(bf.toString());
				hidden_fields.append("\n");
			} else {
				StringBuilder errors = new StringBuilder();
				if (this.getErrors().get(fieldName) != null) {
					errors.append("<ul class=\"errorlist\">");
					errors.append("<li>");
					errors.append(this.getErrors().get(fieldName)); // when this goes to a list,  use bf.geterorrs instead of this.
					errors.append("</li>");
					errors.append("</ul>");
				}
				b.append(String.format("<tr%s><th>%s</th><td>%s%s%s</td></tr>\n", htmlClassAttr, bf.getLabelHtml(), errors.toString(), bf.toString(), bf.getHelpText()));
			}
		}
		if (hidden_fields.length() ==0 ) { // no hidden fields
			return b.toString();
		} else if (b.length() > 11) { // insert hidden field into the last cell of the table.
			return b.insert(b.length()-11, hidden_fields).toString();
		} else { // must only have hidden fields, TODO this is probably not correct as we should probably create a tr/td to contain the fields.
			return b.append(hidden_fields).toString();
		}
	}

	@SuppressWarnings("unchecked") // the cast had better work if the field is doing its job!!
	public <T> T getCleanedValue(Field<T> field) {
		return (T)getCleanedData().get(field.getName());
	}

	public Map<String, Object> getCleanedData() {
		return cleanedData;
	}

	public Map<String, Field<?>> getFields() {
		if (!init) init();		
		return _fields;
	}

	public Map<String, Object> getInitial() {
		if (initial == null) {
			initial = new HashMap<String, Object>();
		}
		return initial;
	}

	public Form setInitial(Map<String, Object> initial) {
		this.initial = initial;
		return this;
	}

	public Map<String, String[]> getData() {
		return data;
	}

	public String getPrefix() {
		return prefix;
	}

	public Form setPrefix(String prefix) {
		this.prefix = prefix;
		return this;
	}

	/**
	 * Returns a bound field for the requested field.
	 * @param field
	 * @return
	 */
	public BoundField get(String field) {		
		if (!getFields().containsKey(field)) {
			return null;
		}

		Field f = getFields().get(field);
		if (f.getName() == null) {
			f.setName(field);
		}

		return new BoundField(f, this, f.getName());
	}

	/**
	 * Returns an iterator of the BoundFields in the form.
	 */
	public Iterator<BoundField> iterator() {
		List<BoundField> boundFields = new ArrayList<BoundField>();
		for (Entry<String, Field<?>> e : getFields().entrySet()) {
			Field<?> field = e.getValue();
			String fieldName = field.getName() == null ? e.getKey() : field.getName();
			BoundField bf = new BoundField(field, this, fieldName);

			boundFields.add(bf);
		}
		return boundFields.iterator();
	}

	public String getId() {
		return id;
	}

	public AbstractForm setId(String id) {
		this.id = id;
		return this;
	}

	public List<String> getNonFieldErrors() {
		List<String> nonFieldErrorList = new ArrayList<String>();
		if (errors != null && errors.containsKey(NON_FIELD_ERRORS)) {
			nonFieldErrorList.add(errors.get(NON_FIELD_ERRORS));
		}
		return nonFieldErrorList;
	}

	public Form setReadOnly(boolean readOnly) {
		this.readOnly = readOnly;
		return this;
	}

	public boolean isReadOnly() {
		return this.readOnly;
	}

	public String getErrorCssClass() {
		return errorCssClass;
	}

	public void setErrorCssClass(String errorCssClass) {
		this.errorCssClass = errorCssClass;
	}

	public String getRequiredCssClass() {
		return requiredCssClass;
	}

	public void setRequiredCssClass(String requiredCssClass) {
		this.requiredCssClass = requiredCssClass;
	}

	public String getHiddenFieldsHtml() {
		StringBuilder hidden_fields = new StringBuilder();

		for (Entry<String, Field<?>> entry : getFields().entrySet()) {
			Field<?> field = entry.getValue();
			if (field.isHidden()) {
				String fieldName = field.getName() == null ? entry.getKey() : field.getName();
				BoundField bf = new BoundField(field, this, fieldName);
				hidden_fields.append(bf.toString());
				hidden_fields.append("\n");
			}
		}
		return hidden_fields.toString();
	}
}
